/* Copyright 2024 Marimo. All rights reserved. */

// @ts-expect-error - vega-parser is not typed
import { parse } from "vega-parser";
import { describe, expect, it } from "vitest";
import { invariant } from "@/utils/invariant";
import { makeSelectable } from "../make-selectable";
import { getSelectionParamNames } from "../params";
import type { VegaLiteSpec } from "../types";

describe("makeSelectable", () => {
  it("should return correctly if mark is not string", () => {
    const spec = {
      mark: {
        type: "point",
      },
    } as VegaLiteSpec;
    expect(makeSelectable(spec, {})).toMatchSnapshot();
  });

  it("should return correctly if mark is a string", () => {
    const spec = {
      mark: "point",
    } as VegaLiteSpec;
    expect(makeSelectable(spec, {})).toMatchSnapshot();
  });

  it("should return the same spec if selection is false", () => {
    const spec = {
      mark: "point",
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {
      chartSelection: false,
      fieldSelection: false,
    });
    expect(newSpec).toEqual(spec);
    expect(getSelectionParamNames(newSpec)).toEqual([]);
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should return the same spec for not-defined and true", () => {
    const spec = {
      mark: "point",
    } as VegaLiteSpec;
    expect(
      makeSelectable(spec, {
        chartSelection: true,
        fieldSelection: true,
      }),
    ).toEqual(makeSelectable(spec, {}));
    expect(makeSelectable(spec, {})).toMatchSnapshot();
  });

  it("should return the same spec if mark is not in spec", () => {
    const spec1 = {
      mark: "point",
    } as VegaLiteSpec;
    const spec2 = {
      mark: {
        type: "point",
      },
    } as VegaLiteSpec;
    expect(makeSelectable(spec1, {})).toEqual(makeSelectable(spec2, {}));
    expect(makeSelectable(spec1, {})).toMatchSnapshot();
  });

  it("should return correctly if overlapping encodings", () => {
    const spec = {
      config: {
        view: {
          continuousHeight: 300,
        },
      },
      data: { url: "data/cars.json" },
      encoding: {
        color: {
          field: "Origin",
          type: "nominal",
        },
        x: {
          field: "Horsepower",
          type: "quantitative",
        },
        y: {
          field: "Miles_per_Gallon",
          type: "quantitative",
        },
      },
      mark: {
        type: "point",
      },
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {
      chartSelection: true,
      fieldSelection: true,
    });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([
      "legend_selection_Origin",
      "select_point",
      "select_interval",
      "pan_zoom",
    ]);
  });

  it("should skip field selection if empty or false", () => {
    const spec = {
      config: {
        view: {
          continuousHeight: 300,
        },
      },
      data: { url: "data/cars.json" },
      encoding: {
        color: {
          field: "Origin",
          type: "nominal",
        },
        x: {
          field: "Horsepower",
          type: "quantitative",
        },
        y: {
          field: "Miles_per_Gallon",
          type: "quantitative",
        },
      },
      mark: {
        type: "point",
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {
      chartSelection: true,
      fieldSelection: false,
    });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([
      "select_point",
      "select_interval",
      "pan_zoom",
    ]);

    // These are the same
    expect(
      makeSelectable(spec, { chartSelection: true, fieldSelection: false }),
    ).toEqual(
      makeSelectable(spec, { chartSelection: true, fieldSelection: [] }),
    );
  });

  it("should return correctly with multiple encodings", () => {
    const spec = {
      mark: "point",
      encoding: {
        color: {
          field: "colorField",
          type: "nominal",
        },
        size: {
          field: "sizeField",
          type: "quantitative",
        },
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([
      "legend_selection_colorField",
      "legend_selection_sizeField",
      "select_point",
      "select_interval",
      "pan_zoom",
    ]);
  });

  it("should return correctly if existing legend selection", () => {
    const spec = {
      config: {
        view: {
          continuousHeight: 300,
        },
      },
      data: {
        url: "https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/data/unemployment-across-industries.json",
      },
      encoding: {
        color: {
          field: "series",
          scale: { scheme: "category20b" },
          type: "nominal",
        },
        opacity: {
          condition: { param: "param_1", value: 1 },
          value: 0.2,
        },
        x: {
          axis: { domain: false, format: "%Y", tickSize: 0 },
          field: "date",
          timeUnit: "yearmonth",
          type: "temporal",
        },
        y: {
          aggregate: "sum",
          axis: null,
          field: "count",
          stack: "center",
          type: "quantitative",
        },
      },
      mark: { type: "area" },
      params: [
        {
          bind: "legend",
          name: "param_1",
          select: { fields: ["series"], type: "point" },
        },
      ],
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([
      "param_1",
      "select_point",
      "pan_zoom",
    ]);
  });

  it("should work for multi-layered charts", () => {
    const spec = {
      layer: [
        {
          mark: {
            type: "errorbar",
            ticks: true,
          },
          encoding: {
            x: {
              field: "yield_center",
              scale: {
                zero: false,
              },
              title: "yield",
              type: "quantitative",
            },
            xError: {
              field: "yield_error",
            },
            y: {
              field: "variety",
              type: "nominal",
            },
          },
        },
        {
          mark: {
            type: "point",
            color: "black",
            filled: true,
          },
          encoding: {
            x: {
              field: "yield_center",
              type: "quantitative",
            },
          },
        },
      ],
      data: { name: "source" },
      width: "container",
      datasets: {
        source: [
          {
            yield_error: 7.5522,
            yield_center: 32.4,
          },
          {
            yield_error: 6.9775,
            yield_center: 30.966_67,
          },
          {
            yield_error: 3.9167,
            yield_center: 33.966_665,
          },
          {
            yield_error: 11.9732,
            yield_center: 30.45,
          },
        ],
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {
      chartSelection: true,
    });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    expect(getSelectionParamNames(newSpec)).toEqual([
      "pan_zoom",
      "select_point_1",
      "select_interval_1",
    ]);
  });

  it("should work for multi-layered charts with different selections", () => {
    const spec = {
      layer: [
        {
          mark: { type: "bar", cornerRadius: 10, height: 10 },
          encoding: {
            x: {
              aggregate: "min",
              field: "temp_min",
              scale: { domain: [-15, 45] },
              title: "Temperature (°C)",
              type: "quantitative",
            },
            x2: { aggregate: "max", field: "temp_max" },
            y: {
              field: "date",
              timeUnit: "month",
              title: null,
              type: "ordinal",
            },
          },
        },
        {
          mark: { type: "text", align: "right", dx: -5 },
          encoding: {
            text: {
              aggregate: "min",
              field: "temp_min",
              type: "quantitative",
            },
            x: { aggregate: "min", field: "temp_min", type: "quantitative" },
            y: { field: "date", timeUnit: "month", type: "ordinal" },
          },
        },
        {
          mark: { type: "text", align: "left", dx: 5 },
          encoding: {
            text: {
              aggregate: "max",
              field: "temp_max",
              type: "quantitative",
            },
            x: { aggregate: "max", field: "temp_max", type: "quantitative" },
            y: { field: "date", timeUnit: "month", type: "ordinal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {
      chartSelection: true,
    });

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([
      "select_point_0",
      "select_interval_0",
      "pan_zoom",
      "select_point_1",
      "select_interval_1",
      "select_point_2",
      "select_interval_2",
    ]);
  });

  it("should work for geoshape", () => {
    const spec = {
      mark: "geoshape",
      encoding: {
        color: {
          datum: "red",
          type: "nominal",
        },
        x: {
          field: "x",
          type: "quantitative",
        },
        y: {
          field: "y",
          type: "quantitative",
        },
      },
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toEqual([]);
  });

  it("should work for layered charts, with existing selection", () => {
    const spec = {
      data: {
        name: "data-34c3e7380bd529c27667c64406db8bb8",
      },
      datasets: {
        "data-34c3e7380bd529c27667c64406db8bb8": [
          {
            Level1: "a",
            count: 1,
            stage: "france",
          },
          {
            Level1: "b",
            count: 2,
            stage: "france",
          },
          {
            Level1: "c",
            count: 3,
            stage: "england",
          },
        ],
      },
      layer: [
        {
          encoding: {
            color: {
              condition: {
                field: "stage",
                param: "param_22",
              },
              value: "lightgray",
            },
            x: {
              field: "Level1",
              sort: {
                order: "descending",
              },
              title: "Subpillar",
              type: "nominal",
            },
            y: {
              field: "count",
              title: "Number of Companies",
              type: "quantitative",
            },
          },
          mark: {
            type: "bar",
          },
          name: "view_21",
        },
        {
          encoding: {
            color: {
              datum: "england",
            },
            y: {
              datum: 2,
            },
          },
          mark: {
            strokeDash: [2, 2],
            type: "rule",
          },
        },
      ],
      params: [
        {
          name: "param_22",
          select: {
            encodings: ["x"],
            type: "point",
          },
          views: ["view_21"],
        },
      ],
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toMatchInlineSnapshot(`
      [
        "param_22",
      ]
    `);
  });

  it("should work for layered charts, with existing legend selection", () => {
    const spec = {
      data: {
        name: "data-34c3e7380bd529c27667c64406db8bb8",
      },
      datasets: {
        "data-34c3e7380bd529c27667c64406db8bb8": [
          {
            Level1: "a",
            count: 1,
            stage: "france",
          },
          {
            Level1: "b",
            count: 2,
            stage: "france",
          },
          {
            Level1: "c",
            count: 3,
            stage: "england",
          },
        ],
      },
      layer: [
        {
          encoding: {
            color: {
              condition: {
                field: "stage",
                param: "param_22",
              },
              value: "lightgray",
            },
            x: {
              field: "Level1",
              sort: {
                order: "descending",
              },
              title: "Subpillar",
              type: "nominal",
            },
            y: {
              field: "count",
              title: "Number of Companies",
              type: "quantitative",
            },
          },
          mark: {
            type: "bar",
          },
          name: "view_21",
        },
        {
          encoding: {
            color: {
              datum: "england",
            },
            y: {
              datum: 2,
            },
          },
          mark: {
            strokeDash: [2, 2],
            type: "rule",
          },
        },
      ],
      params: [
        {
          name: "param_22",
          bind: "legend",
          select: {
            fields: ["x"],
            type: "point",
          },
          views: ["view_21"],
        },
      ],
    } as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    expect(getSelectionParamNames(newSpec)).toMatchInlineSnapshot(`
      [
        "param_22",
      ]
    `);
  });

  it.each([
    "errorbar",
    "errorband",
    "boxplot",
  ])("should return the same spec if mark is %s", (mark) => {
    const spec = {
      mark,
    } as unknown as VegaLiteSpec;
    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toEqual(spec);
    expect(getSelectionParamNames(newSpec)).toEqual([]);
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should add legend selection to composite charts (issue #6676)", () => {
    // Test case from https://github.com/marimo-team/marimo/issues/6676
    const spec = {
      layer: [
        {
          mark: "rule",
          encoding: {
            x: { field: "x_value", type: "quantitative" },
            y: { field: "upper", type: "quantitative" },
            y2: { field: "lower" },
            color: {
              field: "category",
              type: "nominal",
            },
          },
        },
        {
          mark: { type: "point", filled: true, size: 60 },
          encoding: {
            x: { field: "x_value", type: "quantitative" },
            y: { field: "value", type: "quantitative" },
            color: {
              field: "category",
              type: "nominal",
            },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {
      chartSelection: true,
      fieldSelection: true,
    });

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    const paramNames = getSelectionParamNames(newSpec);
    // Should have legend selection for category field
    expect(paramNames).toContain("legend_selection_category");
    // Should NOT have duplicate legend params
    expect(
      paramNames.filter((name) => name === "legend_selection_category"),
    ).toHaveLength(1);
  });

  it("should not duplicate legend params when multiple layers have same color field", () => {
    const spec = {
      layer: [
        {
          mark: "line",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: { type: "point", size: 100 },
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    const paramNames = getSelectionParamNames(newSpec);

    // Should have exactly one legend_selection_category param
    const legendParams = paramNames.filter((name) =>
      name.startsWith("legend_selection_category"),
    );
    expect(legendParams).toHaveLength(1);
    expect(legendParams[0]).toBe("legend_selection_category");
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should add bin_coloring param for binned charts", () => {
    const spec = {
      mark: "bar",
      encoding: {
        x: { field: "x", bin: true, type: "quantitative" },
        y: { aggregate: "count", type: "quantitative" },
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, { chartSelection: true });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    const paramNames = getSelectionParamNames(newSpec);

    // Should have point selection and bin_coloring param
    expect(paramNames).toContain("select_point");
    expect(paramNames).toContain("bin_coloring");
    // Should NOT have interval selection for binned charts
    expect(paramNames).not.toContain("select_interval");
  });

  it("should add bin_coloring param for 2D binned histogram", () => {
    const spec = {
      mark: "rect",
      encoding: {
        x: { field: "x", bin: true, type: "quantitative" },
        y: { field: "y", bin: true, type: "quantitative" },
        color: { aggregate: "count", type: "quantitative" },
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, { chartSelection: true });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    const paramNames = getSelectionParamNames(newSpec);

    // Should have point selection and bin_coloring param
    expect(paramNames).toContain("select_point");
    expect(paramNames).toContain("bin_coloring");
  });

  it("should add bin_coloring param for layered binned charts", () => {
    const spec = {
      layer: [
        {
          mark: "bar",
          encoding: {
            x: { field: "x", bin: true, type: "quantitative" },
            y: { aggregate: "count", type: "quantitative" },
          },
        },
        {
          mark: "rule",
          encoding: {
            x: { aggregate: "mean", field: "x", type: "quantitative" },
            color: { value: "red" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, { chartSelection: true });
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
    const paramNames = getSelectionParamNames(newSpec);

    // First layer should have point selection and bin_coloring
    expect(paramNames).toContain("select_point_0");
    expect(paramNames).toContain("bin_coloring_0");
  });

  it("should prefer point selection for binned charts even when chartSelection is true", () => {
    const spec = {
      mark: "bar",
      encoding: {
        x: { field: "x", bin: { maxbins: 20 }, type: "quantitative" },
        y: { aggregate: "count", type: "quantitative" },
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, { chartSelection: true });
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should only have point selection for binned charts (not interval)
    expect(paramNames).toContain("select_point");
    expect(paramNames).not.toContain("select_interval");
    expect(paramNames).toContain("bin_coloring");
  });

  it("should not add bin_coloring when chartSelection is false", () => {
    const spec = {
      mark: "bar",
      encoding: {
        x: { field: "x", bin: true, type: "quantitative" },
        y: { aggregate: "count", type: "quantitative" },
      },
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, { chartSelection: false });
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should not have any chart selection params
    expect(paramNames).not.toContain("select_point");
    expect(paramNames).not.toContain("bin_coloring");
  });

  it("should collect legend fields from multiple layers with different fields", () => {
    const spec = {
      layer: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            size: { field: "size_field", type: "quantitative" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should have legend params for both fields
    expect(paramNames).toContain("legend_selection_category");
    expect(paramNames).toContain("legend_selection_size_field");
  });

  it("should add legend selection to vconcat specs", () => {
    const spec = {
      vconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: "bar",
          encoding: {
            x: { field: "x", type: "nominal" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should have legend selection for category field
    expect(paramNames).toContain("legend_selection_category");
  });

  it("should add legend selection to hconcat specs", () => {
    const spec = {
      hconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "series", type: "nominal" },
          },
        },
        {
          mark: "line",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "series", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should have legend selection for series field
    expect(paramNames).toContain("legend_selection_series");
  });

  it("should add legend selection to nested vconcat(hconcat(...)) specs", () => {
    const spec = {
      vconcat: [
        {
          hconcat: [
            {
              mark: "point",
              encoding: {
                x: { field: "x", type: "quantitative" },
                y: { field: "y", type: "quantitative" },
                color: { field: "category", type: "nominal" },
              },
            },
            {
              mark: "bar",
              encoding: {
                x: { field: "x", type: "nominal" },
                y: { field: "y", type: "quantitative" },
                color: { field: "category", type: "nominal" },
              },
            },
          ],
        },
        {
          mark: "line",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    const paramNames = getSelectionParamNames(newSpec);

    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();

    // Should have legend selection for category field
    expect(paramNames).toContain("legend_selection_category");
  });

  it("should hoist params to top level in hconcat(vconcat(...)) and apply opacity to nested specs", () => {
    const spec = {
      hconcat: [
        {
          vconcat: [
            {
              mark: "point",
              encoding: {
                x: { field: "Horsepower", type: "quantitative" },
                y: { field: "Miles_per_Gallon", type: "quantitative" },
                color: { field: "Origin", type: "nominal" },
              },
              height: 100,
            },
            {
              mark: "point",
              encoding: {
                x: { field: "Horsepower", type: "quantitative" },
                y: { field: "Miles_per_Gallon", type: "quantitative" },
                color: { field: "Origin", type: "nominal" },
              },
              height: 100,
            },
          ],
        },
        {
          mark: "point",
          encoding: {
            x: { field: "Horsepower", type: "quantitative" },
            y: { field: "Miles_per_Gallon", type: "quantitative" },
            color: { field: "Origin", type: "nominal" },
          },
          height: 100,
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Params should be hoisted to top level
    expect(newSpec.params).toBeDefined();
    expect(newSpec.params!.length).toBeGreaterThan(0);

    // Should have legend selection for Origin field
    const paramNames = getSelectionParamNames(newSpec);
    expect(paramNames).toContain("legend_selection_Origin");

    // Nested specs should NOT have params (they should be hoisted)
    if ("hconcat" in newSpec) {
      for (const subSpec of newSpec.hconcat) {
        if ("vconcat" in subSpec) {
          invariant("vconcat" in subSpec, "subSpec should have vconcat");
          for (const innerSpec of subSpec.vconcat) {
            expect("params" in innerSpec).toBe(false);
            // But should have opacity encoding
            if ("mark" in innerSpec) {
              expect(innerSpec.encoding?.opacity).toBeDefined();
            }
          }
        } else if ("mark" in subSpec) {
          expect(subSpec.params).toBeUndefined();
          // But should have opacity encoding
          expect(subSpec.encoding?.opacity).toBeDefined();
        }
      }
    }
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should only hoist common params with same select.type and select.encodings", () => {
    // All point charts with x,y encodings - should hoist point/interval params
    const spec = {
      hconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Common params should be hoisted
    expect(newSpec.params).toBeDefined();
    const topLevelParamNames = getSelectionParamNames(newSpec);

    // Legend param should be hoisted (common across all)
    expect(topLevelParamNames).toContain("legend_selection_category");

    // Point and interval params should be hoisted (same type and encodings)
    expect(topLevelParamNames).toContain("select_point");
    expect(topLevelParamNames).toContain("select_interval");

    // Nested specs should not have params
    if ("hconcat" in newSpec) {
      for (const subSpec of newSpec.hconcat) {
        expect("params" in subSpec).toBe(false);
      }
    }
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should not hoist params when specs have different selection types", () => {
    // One bar chart (point+interval) and one area chart (point only) - different selection strategies
    const spec = {
      hconcat: [
        {
          mark: "bar",
          encoding: {
            x: { field: "x", type: "nominal" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: "area",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Legend param should be hoisted (common across all)
    const topLevelParamNames = getSelectionParamNames(newSpec);
    expect(topLevelParamNames).toContain("legend_selection_category");

    // But chart selection params should NOT be hoisted (different types)
    // Bar gets point+interval with x encoding, area gets point with color encoding
    if ("hconcat" in newSpec) {
      const subspecParams = newSpec.hconcat.flatMap((subSpec) =>
        "params" in subSpec ? subSpec.params : [],
      );
      expect(subspecParams.length).toBeGreaterThan(0);
    }
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should not hoist params when specs have different encodings", () => {
    // One chart with x,y selection and one with color selection
    const spec = {
      hconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
          },
        },
        {
          mark: "arc",
          encoding: {
            theta: { field: "value", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Chart selection params should NOT be hoisted (different encodings)
    // Point chart uses x,y encodings, arc chart uses color encoding
    if ("hconcat" in newSpec) {
      const subspecParams = newSpec.hconcat.flatMap((subSpec) =>
        "params" in subSpec ? subSpec.params : [],
      );
      // Both subspecs should have their own params
      expect(subspecParams.length).toBeGreaterThan(0);
    }
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should hoist only common legend params when chart selections differ", () => {
    const spec = {
      hconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
        {
          mark: "bar",
          encoding: {
            x: { field: "x", type: "nominal" },
            y: { field: "y", type: "quantitative" },
            color: { field: "category", type: "nominal" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Common legend param should be hoisted
    const topLevelParamNames = getSelectionParamNames(newSpec);
    expect(topLevelParamNames).toContain("legend_selection_category");

    // But chart-specific params may or may not be hoisted depending on if they match
    // The point chart has x,y encodings, bar has x encoding - these differ
    if ("hconcat" in newSpec) {
      const subspecParams = newSpec.hconcat.flatMap((subSpec) =>
        "params" in subSpec ? subSpec.params : [],
      );
      // Each subspec should have its own chart selection params
      expect(subspecParams.length).toBeGreaterThan(0);
    }
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should handle empty concat specs gracefully", () => {
    const spec = {
      hconcat: [],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});
    expect(newSpec).toEqual(spec);
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });

  it("should handle concat with only one subspec", () => {
    const spec = {
      hconcat: [
        {
          mark: "point",
          encoding: {
            x: { field: "x", type: "quantitative" },
            y: { field: "y", type: "quantitative" },
          },
        },
      ],
    } as VegaLiteSpec;

    const newSpec = makeSelectable(spec, {});

    // Should hoist params even with single subspec
    expect(newSpec.params).toBeDefined();
    const topLevelParamNames = getSelectionParamNames(newSpec);
    expect(topLevelParamNames.length).toBeGreaterThan(0);
    expect(newSpec).toMatchSnapshot();
    expect(parse(newSpec)).toBeDefined();
  });
});
